fix(cli): generated migrations emit the character column driver-sql creates - #16298
Conversation
…reates (#16091) Both migration generators capped a `text` field at VARCHAR(255) while `driver-sql` creates an unbounded `text` column for it, so a 300-character value the platform stores was refused by every generated table with `value too long for type character varying(255)`. #15521's ruling names this card and settles its direction -- the generator follows the driver, as #15040 already did for the `id` column in this same file. Driven on a private PostgreSQL 16.13 cluster, all three producers run from one object and their columns read back out of `information_schema.columns`. The sweep found nine divergent columns of 26 probed, not one: text driver text gen varchar(255) both formats text+max driver text gen varchar(255) maxLength must NOT size it email+max driver varchar(400) gen varchar(255) maxLength was never read url driver varchar(255) sql varchar(2048) invented width phone driver varchar(255) sql varchar(50) invented width color driver varchar(255) sql varchar(7) invented width All of them now follow `createColumn`'s three arms. The text family is unbounded, because that arm branches on KEYED and a generated migration emits no index; its declared bound is enforced at the write seam, not by the column. The string family takes `declaredVarcharLength`'s answer -- the declaration verbatim in both directions, knex's 255 without one, and TEXT above the varchar ceiling rather than a clamp to it. The catch-all keeps the default width and ignores a declaration, because its stored value is an option code or another row's id rather than the declared string. Driven again afterwards: 0 of 26 columns diverge, and the 300-character write is accepted in all three tables exactly where the platform accepts it and refused in all three exactly where the platform refuses it. `generate-string-family-width.pin.test.ts` asserts that agreement against the driver's own source -- arm membership read from `createColumn`'s case labels, widths read from its own constants -- so a driver that moves fails there instead of leaving the generators quietly wrong. Three existing pin files move with it: two used `text`'s old VARCHAR(255) as a stand-in for the driver's default string column, and one asserted column ordering by searching for a `table.string` call that is now a `table.text` call. Scope is PostgreSQL, the only dialect `--format sql` claims (#15521). The FILE_REFERENCE_TYPES divergence stays recorded and unresolved (#15041). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
📓 Docs Drift CheckThis PR changes 1 package(s): 26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2a068d73a9475c3799a2c7eda76d105c3153b5a9 && git checkout 2a068d73a9475c3799a2c7eda76d105c3153b5a9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4998efa71773154561c471075f4ef12566ecc455 d138a6e714eacdbe471877b056d3f9bcf0726ddc && git checkout -B drift-repro 4998efa71773154561c471075f4ef12566ecc455 && git merge --no-ff d138a6e714eacdbe471877b056d3f9bcf0726ddc
node scripts/docs-audit/affected-docs.mjs --json 4998efa71773154561c471075f4ef12566ecc455
|
…d clause-② requires `Check Changeset`'s LEVEL AXIS (#16055) refuses a PR that declares clause-② YES while grading a package whose `packages/*/src/**` it moves as `patch`. The rule it mechanizes is the maintainer's 2026-09-04 ruling (decision batch #35, on #15294), written out under "WHICH LEVEL" in that step: a purely additive widening of a published package's public surface takes AT LEAST `minor`, and the commit type may raise a bump but never lower it below what the act requires. This branch declares clause-② `yes` and moves `packages/cli/src/**`, so the level and the declaration contradicted each other. Only the level moves here -- the generators, the pins and the measurements are untouched.⚠️ The axis is invisible to the plain `--base origin/main` form of the gate, which reports `LEVEL AXIS: NOT MEASURED` and is neither a pass nor a failure. It is judged only from a `pull_request` event payload, off the `needs:contract-review` carrier or a machine-spelled `Clause-②:` line, so `--event` is the only form that can confirm this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
|
Head moved: Posting this as a comment rather than editing the body, because the body states that its 61-family union was measured at Why the level moved
Driven at both heads, with the form that can read the axis
Read with
The gate reads the committed diff, not the working tree, so the level had to be committed before it could be confirmed. One reading worth flagging, no action takenBoth
⛔ Left alone rather than corrected, because the body is not mine to touch here. It is a one-character fix if it is wanted. Re-measured at
|
…e driver does
CORRECTING THE RECORD. This branch's first commit, the new pin's docblock and
three comments in `generate.ts` all said: "the text family branches on KEYED,
and a generated migration emits no index, so no generated column is ever
keyed." That sentence describes this GENERATOR'S OUTPUT. `createColumn` reads
the object's INPUT. Its `keyed` argument is `indexedKeyColumns(...).get(name)`,
and `indexedKeyColumns` composes `uniqueIndexesFromFields` -- which keys a
column on `field.unique`, a key every `FieldSchema` carries -- with the
object's declared `indexes[]`. Both are DECLARATIONS, both are in the config
these generators already read, and neither has anything to do with what a
migration emits. The generator could have read `unique`; it simply did not.
So a keyed text-family column IS sized from its declaration, at
`keyableTextLength`'s width: the declared `maxLength` verbatim up to
MAX_KEYABLE_VARCHAR_CHARS (768, the widest one utf8mb4 key part holds), and
unbounded above that ceiling or with no usable declaration. Driven on live
PostgreSQL 16.13 against the pre-change tree, one 300-character write into
`{ type: 'text', unique: true, maxLength: 100 }`:
driver varchar(100) REFUSED -- 22001 character varying(100)
sql gen text ACCEPTED -- read back at length 300
ts gen text ACCEPTED -- read back at length 300
The wide direction, which this branch's own body calls the quieter of the two,
inside the family it claimed to have closed. Re-driven after the change, all
three producers REFUSE it, and 0 of 32 keyed character columns diverge.
WHAT MOVES
* `generate.ts` gains `indexKeyColumns`, a mirror of the driver's own
composition -- field-level `unique` at all three spellings, object-level
`indexes[]` unique or not, and the ADR-0120 D3 tenant key part, whose
resolution (`tenancy.enabled`, `tenancy.tenantField`, an
`organization_id` column) is computable from the object alone and so is
mirrored rather than skipped. It also gains `keyableTextChars` and the
transcribed 768 ceiling, kept deliberately separate from
`declaredVarchar`: the two answer different questions of the same key.
* The false sentence is corrected in all four places it reached.
* The new pin gains the keyed arm: the driver-source chain at every link,
the arm membership held equal to `createColumn`'s case labels, the width
sweep at both outcomes, the three unique spellings against the words the
spec rejects, the object-level index half, and the tenant-column half --
each of the last two confirmed against the live cluster before pinning.
TWO RIDERS FROM THE SAME REVIEW
* The pin's catch-all case skipped any member whose plain answer had already
drifted, so it measured that the catch-all takes the driver's default
width only where that already held. Mutating `radio` or `secret` to
'TEXT' passed all 61 tests across all four pin files. The character half
of the catch-all is now DERIVED from the three spec classes `driver-sql`
seeds `JSON_COLUMN_TYPES` from -- imported, never listed -- and
`VARCHAR(255)` is asserted on the rest. Both mutations now redden.
* A comment gave a false reason for transcribing `MAX_VARCHAR_CHARS`:
"`packages/cli` does not depend on the driver at runtime". It does --
`@objectstack/driver-sql` is in this package's `dependencies` at
`workspace:^`. The transcription is still necessary, for two other
reasons: the constant is `protected static`, and #5726 forbids a CLI
production module any static value import of a driver package. The reason
moves; the transcription does not.
The changeset stays `minor` and states the keyed half.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
|
Round-2 attribution, recorded here because the PR body's footer block did not survive the edit. The body above was rewritten by a Durable attribution for this round lives where the platform cannot rewrite it — the branch's own commit trailers: Head at the time of writing: Generated by Claude Code |
`generate.ts` mirrors four things `driver-sql` owns. Two of them were already
falsifiable from the driver: `MAX_KEYABLE_VARCHAR_CHARS` is compared against the
constant's own declaration and `TEXT_FAMILY_TYPES` against `createColumn`'s own
case labels, and a driver-side mutation of either reddens the pin. The other two
mirror driver BODIES, which a source reader cannot see move — mutating
`keyableTextLength` to clamp instead of answering null, and each of five
mutations across `schema-drift`, `computeTenantField` and spec's
`isUniqueDeclared`, left all 69 pins green.
Both are now recomputed from `driver-sql` itself and compared:
- the key set, from the driver's own exported `uniqueIndexesFromFields` and
`normalizeDeclaredIndex` with the tenant column from a `SqlDriver` subclass
that publishes `computeTenantField`, over a swept corpus of 1,224 objects
(every combination of a field-level `unique` spelling, an `indexes[]` entry,
a `tenancy` declaration and a column shape), against the key set read back
out of what both generators emit;
- both widths, from the driver's own `keyableTextLength` and
`declaredVarcharLength` through the same subclass, over 37 declarations
including the coerced and rejected spellings.
A test file is not a CLI production module: #5726 governs
`packages/cli/src/**` production sources, and the gate enforcing it excludes
`*.test.ts` by construction. The package already declares `@objectstack/driver-sql`
and the specifier is already in `KNOWN_UNALIASED_TEST_IMPORTS`, so neither the
dependency graph nor that shrink-only ledger moves.
The differential found one branch of `indexKeyColumns` disagreeing with the
driver, and this fixes it. `normalizeDeclaredIndex` filters an entry's
`nullSafeColumns` against its listed columns, but that filter narrows only
`nullSafeColumns` — its `columns` stay the listed ones in every branch of the
arm. Reading the filter as if it decided the KEY PARTS made
`{ fields: ['f'], unique: 'organization', nullSafeColumns: ['zzz'] }` key
`{organization_id, f}` here against the driver's `{f}`: a column bounded in a
generated migration that the platform leaves unbounded. The condition is now the
driver's own — a non-empty array, nothing more — and the comment claiming the
mirrored branch kept this set from being a strict superset of the driver's is
replaced, since that branch was the one making it exactly that.
`isUniqueDeclared` and `isTenancyDisabled` are imported from
`@objectstack/spec/data` rather than transcribed. Spec is not a driver package,
so #5726 never reached them, and `isTenancyDisabled` is ADR-0066's single
judgment for the registry, the engine and every driver. The transcriptions that
remain now state their real warrant: `MAX_VARCHAR_CHARS`,
`MAX_KEYABLE_VARCHAR_CHARS`, `keyableTextLength`, `declaredVarcharLength` and
`computeTenantField` are `protected` and reach no exported surface, while
`isOrganizationScopedUnique` is exported and is spelled here only because these
generators are synchronous and #5726 leaves a production module `await import()`
alone for a driver package.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
The two scope predicates `generate.ts` mirrors were pinned by reading `schema-drift.ts` for the exact line each is spelled on. That catches a rewording and nothing else: a driver whose vocabulary narrows while the line survives leaves the generators sizing a column the platform would not key, and the pin green. Both are exported, so the pin now ASKS them — `isUniqueScopeDeclared` over sixteen `unique` spellings against the width each produces in the emitted DDL, and `isOrganizationScopedUnique` over the same spellings against whether the tenant column is keyed with them. Measured by mutating the driver's `isUniqueScopeDeclared` to drop the bare-`true` and `'global'` spellings, rebuilding `driver-sql` and re-running: five pins go red, of which four are reachable only through the oracle. This is also the axis the `@objectstack/spec/data` import closes. The generators now reach the same spec `isUniqueDeclared` the driver's wrapper reaches, so a change to that predicate moves both together and opens no divergence at all; what remains falsifiable is the driver's own wrapper moving alone, which is what these two cases catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
|
VERDICT: CHANGES REQUIRED Independent contract review, round 3, at head Clause ② — the declaration is yes, and it is correct on the conformance limb
Findings1. BLOCKING — the key-set "oracle" asks the driver's leaves but re-composes them in the test, so the driver's own composition and wiring can move with every pin greenWhat the pin's Measured, driver-side, with the driver rebuilt each time and the four pin files run at 78 tests (plus my own end-to-end differential, described under "what I attacked"): L2 and L3 are the card's whole subject — the driver changes what it keys, the generated column stays bounded where the platform's is unbounded ( The fix is cheap and needs no new dependency: 2. MUST FIX — a false measurement in a commit message: the corpus is 1,152 objects over 16 index shapes, not "1,224" over "17"The pin's "1,224" and "17" appear in the PR body twice ("a swept corpus of 1,224 objects", "an 3. MUST FIX — residue of the sentence R2 declared false, in the new pin file itself
4. LOW —
|
Contract review ADOPTED — CHANGES REQUIRED, at tier, verbatim. Round 4 dispatched
✅ Tier verification
⭐ Finding 1 is the round-3 dispatch's own failure mode, one layer up — and this seat owns thatRound 3 was dispatched because round 2's mutations all landed on the side the PR authored. The instruction was "mutate the side you MIRROR, not the side you wrote." Round 3 did exactly that at the leaf level — and the review shows the same defect survived at the assembly level:
The measurement that makes this blocking:
⇒ The two mutations that ARE this card's subject leave the pin fully green. A driver that changes what it keys, leaving the generated column bounded where the platform's is unbounded ( ⭐ The generalisable rule, and it is new to this lane: asking the driver's leaves is not asking the driver. An oracle has to enter the real chain at the top and read what actually came out, not re-assemble it from exported parts — otherwise every layer between the leaves and the output is a second copy of the belief, and it goes green forever. ⭐ The reviewer also demonstrated the remedy rather than only prescribing it: ⭐ Finding 2 caught the #16247 hazard BEFORE landingThe corpus is 1,152 objects over 16 index shapes — "1,224" / "17" appear twice in the PR body, in the round-3 report, and in commit Findings 3–7 — recorded, dispositions in the round-4 dispatch
⭐ What the reviewer attacked and could NOT break — this is what makes the CHANGES REQUIRED narrowThe repair itself is verified end to end: a real ⇒ ⭐ The fix is right; the instrument that is supposed to keep it right is not. That is why this is a fourth round and not a rejection. Carriers stay hung. Generated by Claude Code |
CORRECTING THE RECORD, first. Commit 11d0e8d's message states "a swept corpus of 1,224 objects" and "37 declarations". Both counts are wrong, and this queue composes the squash body from the branch's commit messages, so they would land in `main` as written. Counted mechanically by parsing the array literals and confirmed by generating the corpus: keyProbeCorpus() 6 uniques x 16 indexSets x 6 tenancies x 2 shapes = 1,152 WIDTH_DECLARATIONS 38 Four of those sixteen index shapes are the already-normalized ones, not three. Nothing in the suite caught either number: the only size assertion was `> 200`, which every wrong count satisfies. Both are now pinned as exact literals, so a corpus that grows without its stated size growing fails here rather than putting a false measurement into a permanent record. ASKING THE DRIVER'S LEAVES IS NOT ASKING THE DRIVER Round 2 transcribed the driver's answers, and mutating the driver left every pin green. Round 3 asked the driver's exported LEAVES -- `uniqueIndexesFromFields`, `normalizeDeclaredIndex`, `computeTenantField` -- and then RE-COMPOSED them in the test file, which left every layer between those leaves and the emitted column a second copy of the pin's own belief. It never called the driver's own `indexedKeyColumns`, nor `initObjects`' wiring of `tenantField` into it, nor `createColumn`'s dispatch on `keyed`. Measured driver-side at f3661ac, each mutation rebuilt into `dist`: indexedKeyColumns stops recording declared indexes 78 passed (78) initObjects passes tenantField: null into it 78 passed (78) against 764 and 276 of 1,152 objects respectively diverging between the real `initObjects` and the generators. Both of those are this card's own subject -- the driver changes what it keys and the generated column stays bounded where the platform's is unbounded -- and the instrument reported everything fine. The reddening of the pin as it now stands, under both mutations, is recorded in the PR body with its counts. WHAT MOVES The authority in the pin is now `SqlDriver.initObjects` on the in-memory better-sqlite3 driver the file already constructs, read back with `PRAGMA table_info`. That is computeAndRecordTenantField -> indexedKeyColumns -> createColumn -> knex -> an actual column, with nothing re-derived in the test. Two differentials run over it: * the whole 1,152-object corpus, comparing all 4,032 declared columns against both generators' emitted width; * every character TYPE the driver cases or catches -- membership read off `createColumn`'s own case labels and its catch-all derivation, 18 today -- at all 38 declarations, keyed and unkeyed, 1,368 probes. The leaf differential is KEPT underneath, because it localises a failure to one builder, and is now documented as NOT the authority. The width differentials against `keyableTextLength` / `declaredVarcharLength` are kept for the same reason: they say which method body moved, while the real chain also covers `createColumn`'s dispatch onto them. Each probe mints its own table name. `initObjects` takes the ALTER path on a name it has already seen and an ALTER cannot retype a column, so a shared name would report the first probe's answer for all 1,152. The driver's warnings are captured into the subclass rather than printed -- the corpus deliberately carries index shapes whose key parts name no materialized column, and the driver correctly says so 144 times on a green run, which is how a real warning stops being read. `logger` is the driver's own documented injection point; nothing about its behaviour changes and the messages stay available to a failure report. TWO SENTENCES THAT WERE STILL WRONG * The pin still said "`packages/cli` does not depend on the driver at runtime, so the ceiling is transcribed in generate.ts" -- verbatim the reason this branch already established as false, that `generate.ts` carries with a ban, and that the same test file contradicts 500 lines earlier. Replaced with the real reasons: `MAX_VARCHAR_CHARS` is `protected static` and reaches no exported surface, and #5726 leaves a CLI production module only `await import()`, which these synchronous generators cannot use. * `generate.ts`'s `isUniqueScopeDeclared` docblock restated a stale driver comment as present fact. Measured against the built spec, `isUniqueDeclared('organization')` is already `true`, so the disjunct is redundant today and both halves are spec's. The disjunct stays -- it is the driver's spelling and the mirror matches it character for character -- but it is no longer described as a scope spec does not accept. The changeset said the generators invented `2048 / 50 / 7`. Only the SQL format did; the TypeScript format emitted a bare `table.string(name)` for all three. Release-notes input, so it is corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…xt-column-unbounded
|
VERDICT: PASS Independent delta review, round 4, at head F1 — the oracle is now a real oracle: PASSPredicted in writing before each leg ran, then applied one at a time to the DRIVER at
The failures are the predicted assertions and no others:
The next layer. Nothing on the key-set or width path is re-derived in the test file any more on the path the oracle runs: F2 — the corrected counts are correct: PASSCounted from the source myself, not from either round's word:
F3 — the retired sentence is gone: PASS, with one LOW residue
Commit messages over
|
Delta review ADOPTED — PASS, at tier, verbatim. One narrow comment-only round before landing
✅ Tier verification, and a delivery note worth recording153 harness-stamped ⇒ ⛔ This seat did not treat that as a PASS. An absent review is not a clean review, and "no findings reported" is not "no findings". It was resumed twice — the second time with an explicit instruction to stop waiting, read what was on disk, re-run any leg synchronously whose result it had never seen land, and mark anything unfinishable as NOT MEASURED rather than omitting it. The review below is the product of that, and it reports every leg measured, nothing NOT MEASURED. ⭐ New rule for this seat, recorded here rather than only in chat: before tier-verifying or adopting any review, first check that a F1 / F2 / F3 — all pass, and the oracle now catches more than it was asked to
⭐ L4 is the unasked-for gain: round 3 caught that mutation only through the source-text greps. The real-chain oracle now catches it on the columns themselves. And under L2 and L3 the old leaf differential stayed green — round 3's finding reproduced, not taken on trust. F2: counts derived from source by the reviewer — 6 × 16 × 6 × 2 = 1,152, four of the sixteen index ids F3: the retired sentence appears verbatim only in the two ⛔-NOT quotations and two commit-message quotations calling it false; no paraphrase anywhere. Squash body: 7 commits, no closing keyword in any commit body ( ⛔ Why one more round despite a PASSThe review's finding 2 is a measurably false sentence that lands in ⭐ This is the third time that claim has been wrong on this PR. Round 3 flagged it (F7); round 4 retracted it in the PR body and left it standing at three sites in the code. ⇒ The PR body is not what the next fixer reads; the comment beside the constant is. ⇒ Round 5 is dispatched, comment-only: fix that reason at all three sites, and correct Finding 1 filed rather than folded in — #16394The oracle is SQLite ( Carriers stay hung until round 5 lands. ⛔ Nothing flipped ready, enqueued or armed. Generated by Claude Code |
CORRECTING THE RECORD. Three commit messages on this branch, and three sites in the code, give as their FIRST reason for transcribing the driver's constants that those members reach no exported surface. That is false, and this queue composes the squash body from the branch's commit messages, so the sentences below would land in `main` as written: * 9cc1a76 -- "The transcription is still necessary, for two other reasons: the constant is `protected static`, and #5726 forbids a CLI production module any static value import of a driver package." * 11d0e8d -- "`MAX_VARCHAR_CHARS`, `MAX_KEYABLE_VARCHAR_CHARS`, `keyableTextLength`, `declaredVarcharLength` and `computeTenantField` are `protected` and reach no exported surface". * 722880a -- "Replaced with the real reasons: `MAX_VARCHAR_CHARS` is `protected static` and reaches no exported surface, and #5726 leaves a CLI production module only `await import()`". `protected` is a COMPILE-TIME visibility modifier. It removes a member from neither the exported class nor the published types. Measured on this worktree's built `packages/drivers/driver-sql/dist`: index.d.ts:5593 protected static readonly MAX_VARCHAR_CHARS = 16383; index.d.ts:5536 protected static readonly MAX_KEYABLE_VARCHAR_CHARS = 768; index.d.ts:5625 protected declaredVarcharLength(field: any): number | null; index.d.ts:5626 protected keyableTextLength(field: any): number | null; index.d.ts:3501 protected computeTenantField(schema: ...); require('.../driver-sql/dist/index.js').SqlDriver.MAX_VARCHAR_CHARS -> 16383 hasOwnProperty.call(SqlDriver, 'MAX_VARCHAR_CHARS') -> true All five are on the exported `SqlDriver`. The pin test already depends on this: it reaches the driver's own `protected` judgments by subclassing, which it could not do if they were absent from the published types. THE REAL CONSTRAINT, AND IT IS A CHOICE #5726 forbids a CLI production module any static value import of an `@objectstack/driver-*` package -- `schema-migrate.lazy-driver-import.test.ts` scans every non-test `.ts` under `packages/cli/src` -- and what it leaves open is `await import()` at the point of use. These generators are SYNCHRONOUS, so they cannot take it. That is the whole reason, and it is a property of how this package is written rather than of the constants: make the generators async and the transcription can go. This is the THIRD round on the same claim. Round 3's review flagged it, round 4 retracted it in the PR body and left it standing at three sites in the code, which is what produced this round. The PR body is not what the next author reads; the comment beside the constant is. WHAT MOVES -- comments and docblocks only. No behaviour, no test, no pin, no count, no changeset: * `generate.ts`, `MAX_VARCHAR_CHARS`'s docblock: the "protected static, so not on the driver package's exported surface at all" bullet is gone. The reason is now #5726 plus the synchronous generators, and the retracted claim is kept as a banned one beside the "does not depend on the driver at runtime" ban that preceded it, so nobody restates it a fourth time. * `generate.ts`, `isOrganizationScopedUnique`'s docblock: it drew a contrast -- "Unlike {@link MAX_VARCHAR_CHARS}, this one IS on `driver-sql`'s exported surface" -- that the measurement above dissolves. Both reach the exported surface, and both are spelled here for the one reason. Its tail also named the LEAF differential as what makes the spelling safe; the authority since 722880a is `SqlDriver.initObjects` read back with `PRAGMA table_info`, with the leaf differential kept beneath it and explicitly not the authority. * `generate.ts`, `indexKeyColumns`: the same stale attribution -- "the differential ... which now recomputes this whole set from the driver's own exported builders" -- now names the real chain and marks the leaf as not the authority. * `generate-string-family-width.pin.test.ts`: the restatement 722880a put there is retracted in place, beside the earlier false reason that comment already bans. `MAX_KEYABLE_VARCHAR_CHARS`'s docblock inherits by reference -- "Transcribed and pinned for exactly the reasons {@link MAX_VARCHAR_CHARS} gives" -- so it is corrected by the block it cites and needed no edit. Verified: `pnpm --filter @objectstack/cli typecheck` exit 0; the four pin files `Test Files 4 passed (4)` / `Tests 81 passed (81)`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
Round 5 — the transcription reason, corrected at every code siteComment-only, head This round fixes the one thing it left behind: the claim that the driver's transcribed constants "are The claim is false — re-measured on this seat, not relayedAgainst this worktree's built
The real constraint, and it is a CHOICE rather than a property of the constants. #5726 forbids a CLI production module any static value import of an The three code sites, re-derived rather than taken from a line number
R2 — the retired layer, still named as the oracleTwo comments still credited the LEAF differential. Since Three body sentences above are superseded by this comment⛔ Force-push and amend are forbidden, so the commit-message half is corrected the way
The body is left otherwise untouched on purpose — including the machine-read Verification, proportionate to a comment-only diff
Generated by Claude Code |
Fixes #16091
Both migration generators capped a
textfield atVARCHAR(255)whiledriver-sqlcreates an unboundedtextcolumn for it, so a 300-character value the platform stores was refused by every generated table. The direction was ruled in advance on #15521 (comment5557086667, which names this card): the generator follows the driver, the same principle #15040 applied to theidcolumn in this same file. This makes both generators emit the character column the driver actually creates — for the whole character-column family, not only the row the card named.packages/drivers/**is the AUTHORITY here and is read, never edited.Round 2 — the KEYED half, and a correction to the record
The contract review drove 14 probes the first sweep never carried, and two of them still diverged at
83edbc55f3e:Re-driven at that head, a 300-character write into
x_text_uniq_max: driver REFUSED (22001 character varying(100)), sql gen ACCEPTED at length 300, ts gen ACCEPTED at length 300. The WIDE direction — the one this body calls the quieter of the two — still live, inside the family this body claimed to have closed.The sentence that missed it was wrong, and it was wrong in a specific way. It read: the branch is on KEYED, and neither generator emits an index, so no generated column is ever keyed. That describes this GENERATOR'S OUTPUT.
createColumnreads the object's INPUT. Itskeyedargument isindexedKeyColumns(...).get(name);indexedKeyColumnscomposesuniqueIndexesFromFields— which keys a column onfield.unique, a key everyFieldSchemacarries — with the object's declaredindexes[]. Both are DECLARATIONS, both sit in the config these generators already read, and neither has anything to do with what a migration emits. The generator could have readunique; it simply did not. The sentence has been corrected in all four places it reached: three comments ingenerate.ts, the new pin's docblock, and this section of the body. The new commit message states the correction in its own words, because the queue composes the squashed body from the branch's commit messages.The remedy is the preferred one — the generators now follow the driver here too.
generate.tsgainsindexKeyColumns, a mirror ofindexedKeyColumns: field-leveluniqueat all three spellings it accepts (true/'global'/'organization'), object-levelindexes[]whether unique or not, and the ADR-0120 D3 tenant key part, whose resolution (tenancy.enabled,tenancy.tenantField, anorganization_idcolumn) is computable from the object alone and so is mirrored rather than skipped. A keyed text-family column then takeskeyableTextLength's answer: the declared bound verbatim up toMAX_KEYABLE_VARCHAR_CHARS(768), and unbounded above that ceiling or with no usable declaration — never a clamp TO the ceiling, the same rule the string family follows one arm over.textdo not move, as required:x_text_uniqdeclares no bound, andx_text_uniq_bigdeclares 1000, past the key-part ceiling. Both staytextin all three producers.The mirror is spelled here rather than called because these generators are SYNCHRONOUS and #5726 leaves a CLI production module only
await import()for a driver package — the round-3 section below states that reason properly and replaces the one this line originally gave.Re-driven at
9cc1a76df2c, on live PostgreSQL 16.13Three schemas, one per producer, columns read back out of
information_schema.columns.The 32 are the whole text family (8 members read off
createColumn's own case labels) across the four keyed declaration shapes that reach a bound a key part can hold. The 15 that remain are exactlyfile/image/avatar/video/audio— #15041's recorded, deliberately unresolved divergence — and they were divergent before this branch as well.The 300-character write into
x_text_uniq_max, re-driven after the change: REFUSED by all three, exactly where the platform refuses it.Tenant-scoped and object-level shapes were driven against the live driver BEFORE being pinned, never inferred:
Two riders from the same review
R1 — the new pin's catch-all case was vacuous for a drifted member. It read
if (plain.sql !== VARCHAR(255)) continue;, which skips any member whose plain answer has ALREADY regressed — so the case measured its own claim only where that claim already held. Measured: mutatingradio: 'TEXT'orsecret: 'TEXT'inFIELD_TYPE_SQL_MAPpassed all 61 tests across all four pin files. The character half of the catch-all is now DERIVED — the catch-all members minus the three spec classesdriver-sqlseedsJSON_COLUMN_TYPESfrom (MULTI_OPTION_TYPES,STRUCTURED_JSON_TYPES,FILE_REFERENCE_TYPES), imported and never listed — andVARCHAR(255)is asserted on the rest, along with the fact that neithermaxLength, norunique, nor a declared index moves it. Both mutations now redden, measured below.R2 — a false reason in a code comment. It said⚠️ This rider originally ended by extending the same reason to
MAX_VARCHAR_CHARSis transcribed "becausepackages/clidoes not depend on the driver at runtime". It does:@objectstack/driver-sqlis in this package'sdependenciesatworkspace:^. The transcription is still necessary and the REASON moves, not the transcription: the constant isprotected staticonSqlDriver, so it is not on the package's exported surface at all, and #5726 independently forbids a CLI production module any static value import of a driver package (oclifimport()s every command module on every invocation;schema-migrate.lazy-driver-import.test.tsenforces it). Both reasons are now stated, and the false one is named as false so the next reader does not "simplify" the transcription away.indexKeyColumns; that extension was itself overstated and is corrected in round 3 below — the builders that mirror composes ARE exported, and the real reason is a different one.Round 3 — the mirrors get a driver-side falsifier, and one of them was wrong
The delta review re-drove the repair independently (489 columns, 0 divergences) and then asked the question round 2's battery never asked: what happens when the DRIVER moves? M13–M18 all mutated
generate.ts. Mutatingsql-driver.ts/schema-drift.tsinstead splits the four mirrors this file carries into two classes:Two of them read the driver and fail when it moves. Two restated expected values, on a card whose whole subject is generator/driver divergence.
And the differential found
indexKeyColumnsalready disagreeing with the driver in one branch.normalizeDeclaredIndexfilters a pre-normalized entry'snullSafeColumnsagainst its listed columns, but that filter narrows onlynullSafeColumns— itscolumnsstay the listed ones in every branch of that arm. Reading the filter as if it decided the KEY PARTS made one shape diverge:A column bounded in a generated migration that the platform leaves unbounded — this card's defect pointed the other way. Reachable only through the unvalidated authoring door (
IndexSchemais astrictObjectwith nonullSafeColumnskey), and wrong regardless. The condition is now the driver's own, and the comment claiming that mirroring the branch kept this set from being a strict superset of the driver's is replaced: that branch was the one making it exactly that.What the oracle is
Both body-mirrors are now recomputed from⚠️ That sentence originally read "RECOMPUTED from
driver-sql's exported LEAVES and compared against what the generators emit.driver-sqlitself", which is true of the leaf functions and false of the composition around them — see round 4. A test file is not a CLI production module: #5726 governspackages/cli/src/**production sources, andschema-migrate.lazy-driver-import.test.ts— the gate that enforces it — excludes*.test.tsby construction. The package already declares@objectstack/driver-sqland the specifier is already inKNOWN_UNALIASED_TEST_IMPORTS, so neither the dependency graph nor that shrink-only ledger moves.uniqueIndexesFromFieldsandnormalizeDeclaredIndex, with the tenant column from aSqlDriversubclass that publishescomputeTenantField, composed exactly as the unexportedindexedKeyColumnscomposes them —uniquespelling (6), anindexes[]entry (16, four of them already-normalized), atenancydeclaration (6) and a column shape (2). The generators' side is read OBSERVATIONALLY, out of the emitted DDL of both formats, so what is compared is the shipped output rather than an exported internal.keyableTextLengthanddeclaredVarcharLengththrough the same subclass, over 38 declarations including every coerced and rejected spelling ('0x10','1e3',NaN,[100],true).isUniqueScopeDeclaredandisOrganizationScopedUniqueare exported, so the pin now ASKS them over 16 spellings instead of grepping for the line each is written on.The #5726 claim was overstated, and two values are now imported
MAX_VARCHAR_CHARS,MAX_KEYABLE_VARCHAR_CHARS,keyableTextLength,declaredVarcharLengthandcomputeTenantFieldareprotected, so they are not on the package's own exported surface.protectedis a TypeScript visibility rule rather than an export boundary, and this PR's ownDriverOraclereaches three of those five through the exportedSqlDriverby subclassing. What forces the transcription is the same thing that forces it for the exported four — #5726 plus synchronous generators — whichgenerate.tsalready states as the independent reason. The distinction is one without a difference and is not a rule.uniqueIndexesFromFields,normalizeDeclaredIndex,isUniqueScopeDeclaredandisOrganizationScopedUniqueare not: they are ondriver-sql's exported surface, and what forces the transcription for them is that these generators are SYNCHRONOUS, soawait import()— the spelling #5726 leaves open — is unavailable to them. That is now the reason stated in the code, and round 2's sentence giving the exported symbols the unexported ones' reason is corrected here and ingenerate.ts.isUniqueDeclaredandisTenancyDisabledare on@objectstack/spec/data, which is not a driver package and which this module already imported. Both are now IMPORTED rather than transcribed —isTenancyDisabledis ADR-0066's single judgment for the registry, the engine and every driver. That closes an axis rather than pinning it: a change to either now moves the driver and the generators together and can open no divergence at all.Proving the oracle can fail — five DRIVER-side mutations
Each mutated the driver, rebuilt
@objectstack/driver-sql, proved the marker reacheddist/withscripts/ablation-dist-preflight.mjs, ran the four pin files, then restored the driver source by absolute path out ofHEADunder anEXIT INT TERMtrap — proving the restore by blob hash equal to the HEAD blob, an emptygit diff HEAD, a clean whole-treegit status --porcelain, and a second preflight in--absentmode. The mutation leg was proved on disk by counting both the injected marker and the vanished anchor, never by an edit tool's exit code.M1–M4 ran against a 76-test suite and M5 against 78 (the two unique-vocabulary cases landed between them). Under M1–M4 every single failure is in the new oracle section — the 69 pins that existed before this round stayed green through all four, reproducing the review's finding exactly rather than taking it on trust. M5 is the one leg where the old source-text half fires too, because it rewrites a line the pin quotes; four of its five failures are still reachable only through the oracle.
isUniqueDeclaredis not a falsification target any more. Both sides now call it, so it moves them together — which is the point of importing it, and the reason that axis is closed rather than pinned. It was not run, because rebuildingpackages/specregenerates checked-in artifacts and the reading is not worth dirtying the tree for.Round 4 — the oracle enters the real chain, and two false counts are corrected
The round-3 contract review is adopted verbatim. Its verdict on the repair itself is that it holds: a real
SqlDriverdriven throughinitObjectsand read back withPRAGMA table_info, against both generators, over the whole key-set corpus and a width sweep — 0 divergences, 0 driver errors atf3661ac079e, with all five mirrors compared character for character and no infidelity found. Nothing about the repair moves in this round. What moves is the instrument.⭐ Asking the driver's LEAVES is not asking the driver
Round 2 transcribed the driver's answers, and mutating the driver left every pin green. Round 3 asked the driver's exported leaves —
uniqueIndexesFromFields,normalizeDeclaredIndex,computeTenantField— and then re-composed them in the test file. It never called the driver's ownindexedKeyColumns(schema-drift.ts), norinitObjects' wiring oftenantFieldinto it (sql-driver.ts), norcreateColumn's dispatch onkeyed. Every layer between those leaves and the emitted column was therefore a second copy of the pin's own belief, and green no matter what the driver did:Both of those are this card's own subject — the driver changes what it keys, and the generated column stays bounded where the platform's is unbounded — and the instrument reported everything fine.
What the oracle is now
The authority in
generate-string-family-width.pin.test.tsisSqlDriver.initObjectson the in-memory better-sqlite3 driver the file already constructed, read back withPRAGMA table_info. That iscomputeAndRecordTenantField⇢indexedKeyColumns⇢createColumn⇢ knex ⇢ a column that actually exists, with nothing re-derived in the test. Two differentials run over it:createColumn's own case labels plus the derived character half of its catch-all, 18 today — at all 38maxLengthdeclarations, keyed and unkeyed: 1,368 probes. This is the layer the leaf width differentials cannot reach, becausekeyableTextLength's answer says nothing about which armcreateColumnhands the field to.The leaf differential is KEPT underneath, and is now documented as ⛔ not the authority: it localises a failure to one builder, which the real chain cannot do. Cost of the whole real-chain half: the four pin files run in 7.6 s of test time on a shared box, no new dependency.
Two mechanical details worth stating, because both are silent when wrong. Each probe mints its own table name —
initObjectstakes the ALTER path on a name it has already seen and an ALTER cannot retype a column, so a shared name would report the first probe's answer for all 1,152. And the driver's warnings are captured into theDriverOraclesubclass rather than printed: the corpus deliberately carries index shapes whose key parts name no materialized column, and the driver correctly says so 144 times on a green run, which is how a real warning stops being read.loggeris the driver's own documented injection point, nothing about its behaviour changes, and the messages stay available to a failure report.Proving the new oracle can fail — the two legs that were green before
Predicted in writing before either ran — direction, which assertions must catch it, and which must stay green — then applied one at a time to the DRIVER at
722880a1bd8. Each leg proved the mutation on disk (anchor uniqueness asserted before the write, injected-marker count and a changed blob hash after it), rebuilt@objectstack/driver-sql, proved the marker reacheddist/withscripts/ablation-dist-preflight.mjs, ran the four pin files, then restored fromHEADby absolute path under anEXIT INT TERMtrap — proving the restore by blob hash equal to the HEAD blob, an emptygit diff HEAD, a clean whole-treegit status --porcelain, a rebuild, and a second preflight in--absentmode.Both directions and both counts came out exactly as predicted, and the failures are the predicted assertions and no others:
764 of 4032 columns disagree with the column driver-sql actually created, first entrywith-organization_id/unique-absent/plain/tenancy-absent: 'f' driver=text generated=varchar(100), plus the named pre-normalized-arm case. The 764 is the same number the review measured independently on its own differential.276 of 4032 columns disagree, first entrywith-organization_id/unique-absent/idx-org/tenancy-absent: 'organization_id' driver=text generated=varchar(100), plus the real-chain control (expected null to be 100) and the pre-normalized arm's prepending half. Again the same 276 the review measured.⭐ In both legs the LEAF differential stayed green, which is the point: L2 moves the driver's own composition and L3 moves
initObjects' wiring, and a test that re-assembles the answer from exported parts can see neither. After both restores the four pin files are green again at4 passed / 81 passed.The two false counts, corrected
The corpus is 1,152 objects over 16 index shapes —
keyProbeCorpus()is 6 × 16 × 6 × 2 — andWIDTH_DECLARATIONScarries 38 entries, not 37. Both were re-derived on this seat rather than taken on the review's word: the four array literals were parsed mechanically and the corpus generated. Four of the sixteen index shapes are the already-normalized ones, not three."1,224" and "37" reached commit
11d0e8d46f0's message, which this queue composes into the squash body, so they would have landed inmainas written. ⛔ Force-push and amend are forbidden, so the remedy is the follow-up commit on this branch, whose message states both corrected counts and names what it corrects. The body above is corrected in place. Nothing in the suite caught either number, because the only size assertion was> 200— which every wrong count satisfies; both are now pinned as exact literals, so a corpus that grows without its stated size growing fails there rather than putting a false measurement into a permanent record.Two sentences that were still wrong
packages/clidoes not depend on the driver at runtime, so the ceiling is transcribed in generate.ts" — verbatim the reason round 2 established as false, thatgenerate.tsnow carries with a ⛔, and that the same test file contradicts 500 lines earlier. It is replaced by the real reasons:MAX_VARCHAR_CHARSisprotected staticand reaches no exported surface, and objectstack dev 在工作区未构建时刷 12 段无关命令的 MODULE_NOT_FOUND,唯一可执行的那条却指向错误修法 #5726 leaves a CLI production module onlyawait import(), which these synchronous generators cannot use.generate.ts'sisUniqueScopeDeclareddocblock restated a stale driver comment as present fact. Measured against the built spec,isUniqueDeclared('organization')is alreadytrue(field.zod.tslists all three spellings), so the disjunct is redundant today and both halves are spec's. The disjunct stays — it is the driver's spelling and the mirror matches it character for character — but it is no longer described as a scope spec does not accept. ⛔ The driver's own comment, which is the stale source, is not touched.The changeset said the generators invented
2048 / 50 / 7. Only the SQL format did; the TypeScript format emitted a baretable.string(name)for all three, as this body's own table says. Release-notes input, so it is corrected.Re-driven, not quoted
The card asked for its readings to be re-run rather than relayed, and they were, in both rounds. A private PostgreSQL 16.13 cluster was stood up in this container (the same version #15521 used), and all three producers were driven into it from ONE object:
driver-sqlthrough its owninitObjects,os generate migration --format sqlthroughdb.rawof the emitted DDL, andos generate migration(typescript, the default format) by importing the emitted module and callingup(db). Columns were read back out ofinformation_schema.columns; every 300-character probe is a realINSERT.The card's row reproduces exactly:
The class is nine rows wide, not one
The seat asked for the string family to be enumerated rather than the single row repaired. Sweeping every
FIELD_TYPE_SQL_MAPentry and everycreateColumnarm that produces a character type, and driving 26 probe columns through all three producers, nine diverged:The other 17 already agreed and are untouched: the seven remaining text-family members, plus
email/password/select/radio/secret/tree/lookup/master_detail/user/autonumber. All nine were re-driven by name at9cc1a76df2cand all nine agree.Both directions are real failures, and the wide one is the quieter:
maxLength: 400email was accepted by the driver's table and refused by both generated ones.varchar(2048)table and REFUSED by the driver's ownvarchar(255). The scaffold invited a value the platform will not keep, and nothing anywhere names that. The keyed row above is the same failure, found one round later.The three arms, and where the declaration reaches
createColumnsorts every character column into three arms that answer the declaration differently. That is the whole content of this change:keyable === null ? table.text(name) : table.string(name, keyable)overkeyable = keyed ? this.keyableTextLength(field) : null. The branch is on KEYED, andkeyedis the OBJECT'S DECLARATION, not this generator's output:indexedKeyColumnsreadsfield.uniqueandindexes[]. UNKEYED the column is unbounded,maxLengthdeclared or not; KEYED it isvarchar(maxLength)up to 768 and unbounded above.email/url/phone/password) —declared === null ? table.text(name) : table.string(name, declared)overdeclaredVarcharLength(field), which readsmaxLengthunconditionally, with no keyed requirement, and has three outcomes: the declaration verbatim, knex's 255 without one, and TEXT above the varchar ceiling — never a clamp to the ceiling, since a clamp reinstates the very defect.table.string(name)at knex's default width, reading neithermaxLengthnorunique, because the stored value is an option code, an opaque ref or another row's id rather than the declared string. Onlycolordiverged.⭐ A note that contradicts a reasonable expectation, so it is stated loudly and pinned: a declared
maxLengthon an UNKEYEDtextfield does not size its column, and must not. Unkeyed, the bound is enforced at the write seam —schema-drift.tssays so in as many words: "A TEXT column refuses nothing amaxLengthallows … the bound is enforced at the write seam." Sizing it there would look like honouring the author and would be this card's own defect pointed the other way. KEYED is the opposite answer, for the opposite reason: MySQL refuses a TEXT column in a key without a prefix length, so the driver emitsvarchar(n)and the generator must match.generate-string-family-width.pin.test.tspins both, and pins that they really are different answers to the same declaration.Repaired in place beyond the card's own row, declared rather than slipped in
url,phone,color, the wholemaxLengthhalf and the keyed half are not the row the card named. They are repaired here because they are the same defect class asked of the same authority, and the seat's dispatch asked for the class rather than the row. Each is mechanical — the correct shape is fixed bycreateColumn's own arms and by the driver's ownDEFAULT_STRING_VARCHAR_CHARS/MAX_VARCHAR_CHARS/MAX_KEYABLE_VARCHAR_CHARSconstants, with nothing left to judge — and each is evidenced by the driven table above. Repairingtextalone would have shipped a fix that leaves the identical hard failure standing one type over.What is deliberately NOT touched
file/image/avatar/video/audiostay atVARCHAR(2048)against a driver that gives them a JSON column. That is #15041's recorded divergence — two ADR-0104 positions rather than a wrong value — andgenerate-field-type-vocabulary.pin.test.tsalready records it as a divergence rather than coverage. Nothing here rules on it.No MySQL or SQLite claim is made or widened.
--format sqldeclares itself PostgreSQL-only (#15521) and this change stays inside that scope.What the probe set still cannot reach
A sweep is evidence of presence, never of absence — round 2 exists because 26 probes missed a reachable declaration shape, and round 3 exists because a hand-listed set of key-set cases missed a reachable index shape. Stated so the next reader does not have to re-derive it:
FieldTypemembers at all. A field with notypekey, and atypestring that is not a member — the unvalidated authoring door. Both diverge, measured, and are filed as [finding] driver-sql and both migration generators default an absent or unknown fieldtypeto DIFFERENT families —stringversustext, so the unvalidated authoring door produces two different columns #16319 rather than repaired here.real, generatorsnumeric, andratingisrealagainstinteger#16318); the JSON families are [finding] packages/cli generate.ts: both migration generators ignoremultiple: true, so a multi-valued field gets a scalar column whileos generate typesgives it an array type #14829 / [finding] FILE_REFERENCE_TYPES disagree about their column:driver-sqlputs file/image/avatar/video/audio inJSON_COLUMN_TYPES,packages/cligenerate.ts gives themVARCHAR(2048)— and neither side is obviously the one that should move #15041's question and are pinned elsewhere.uniqueconstraints ([finding]os generate migrationemits no declared index at all — a generated table carries none of the object'suniqueconstraints, while driver-sql creates them #16317).--format sqlclaims.The pin, and proving it can fail
generate-string-family-width.pin.test.tsasserts agreement with the driver, read off the driver's own source rather than transcribed, and — since round 3 — recomputed from the driver itself where the driver's BODY is what is mirrored. Arm MEMBERSHIP is read out ofcreateColumn's own case labels (so a type joining or leaving an arm changes what is measured with nobody editing the test), all three widths are read off the driver's own constants, the wholekeyedchain is asserted link by link insql-driver.tsandschema-drift.ts, and every extractor carries a non-vacuity control.Round 2's falsification conditions were written down and their direction predicted before each ran, then applied one at a time to
generate.tsat9cc1a76df2c. Every leg proved the mutation had landed on disk by counting the removed and the injected text — never by an edit tool's exit code — and proved the restore by observed state (git diff HEADempty AND blob hash equal to the HEAD blob), under anEXIT INT TERMtrap with absolute paths.M13 and M14 are R1's proof: those two mutations passed all 61 tests before that round.⚠️ Every one of those six mutated the GENERATOR. Round 3's battery above mutates the DRIVER, which is the direction that tells you whether a mirror is a mirror.
Round 1's ablation stands as recorded:
origin/main'sgenerate.tsrestored over the fix with the tests left in place, the new pin's measurement cases red and its control case GREEN,generate-field-type-vocabulary.pin.test.tsred at its anti-vacuity assertion,generate-multiple-json-column.pin.test.tsred at itssingle_textcontrol, andgenerate-builtin-id-column.pin.test.tsgreen deliberately — the edit there made an ordering assertion column-method agnostic, true on both trees.Three pin files moved, and why each had to
Each of these went red on the fix and is repaired toward the driver rather than around it:
generate-field-type-vocabulary.pin.test.tsassertedsqlColumn('autonumber') === sqlColumn('text'). Both wereVARCHAR(255), which madetexta usable stand-in for "the driver's default string column"; it is not one any more. It now compares againstlookup, whosetable.string(name)arm is asserted from the driver in the same breath, plus an anti-vacuity assertion that the comparator is genuinely a different answer from the text family's.generate-multiple-json-column.pin.test.tsused atextfield as its scalar-versus-JSON control. TEXT is still scalar, so the control keeps its job at its new value, and it now also asserts that the scalar answer differs from the flagged one.generate-builtin-id-column.pin.test.tsasserted ordering by searching fortable.string('title'). Oncetitlebecametable.text, that search returned-1— and "less than -1" reads as a passing comparison until you notice what it is less than. It now matches on the field NAME, and asserts the column was found at all.Verification
Round 4 verification, taken at
fd79a125d1fClean tree, exit codes captured by redirect-then-capture and read from each gate's own verdict line, never after a pipe and never from a bare
$?. This head is722880a1bd8merged withorigin/mainatf377394ae2c(a merge, ⛔ never a rebase), followed bypnpm install --frozen-lockfile, a rebuild of the dependency closure andrm -rf packages/runtime/.objectstack.Test Files 4 passed (4) / Tests 81 passed (81)— 78 before this round.7.60sof test time, of which the whole real-chain half is ~5s.pnpm --filter @objectstack/cli test— the package's own suite, in two runs. The first reported264 passed / 7 failed (271), and all seven failures were read as NOT MEASURED rather than as red: every one refused withpackages/cli is not built (./dist/index.js is absent) … Run: pnpm --filter @objectstack/cli build. That build was run and the seven were re-run at7 passed (7) / 31 passed (31). Net: 271 files, 3,253 passed, 6 expected-fail, 31 skipped, 0 failed.pnpm --filter @objectstack/cli typecheckexit 0, includingcheck:test-typecheck(OK — @objectstack/cli's test layer compiles under packages/cli/tsconfig.test.json; 3 file(s) / 28 error(s) / 6 pinned signature(s) held), unchanged from before this round. Coverage measured rather than assumed:tsc -p tsconfig.json --noEmit --listFileslistssrc/commands/generate.ts,src/commands/generate-string-family-width.pin.test.tsandpackages/drivers/driver-sql/dist/index.d.ts, so the oracle's import is type-checked against the artifact it actually resolves.pnpm lint— the FULL repo sweep again this round (eslint . --no-inline-config), exit 0. No narrowing, so no narrowing argument is owed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the tree atfd79a125d1f:Reconciliation — 57 famil(ies), and the--commandsharvest is 57 lines, so the two forms agree. The families whose INPUTS this diff supplies were run and are green in their own verdict lines:check:nul-bytes(OK (scanned 8032 text file(s) … no raw ASCII control bytes)),check:test-source-alias(OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/— this round adds none),check:cross-package-test-inputs(OK: 27 package(s) read outside themselves, all declared),check:type-source-resolution(OK — 132 tsc program(s) across 78 packages scanned; 61 registered),check:logger-receiver-detach(every log channel keeps its receiver: 2585 non-test TS file(s) walked, 0 detach(es)— run because this round overrides the driver'sloggerin a subclass),check:doc-authoring,check:published-files,check:engine-double-contract,check:objectui-changeset,check:changeset-gate-self-tests, and the three changeset gates plus their self-tests (check-changeset-no-major,check-empty-changeset,check-adr-0087-registration).--base origin/mainform reportsLEVEL AXIS: NOT MEASURED — no clause-② declaration was readable for this PR … no pull_request payload was available, which is neither a pass nor a failure (check:react-declaration-parity 是唯一没接进任何 workflow 的源码审计门禁,且无 MANIFEST 时静默 skip 退出 0 —— 它现在永远不可能红 #4690). Driven with--eventcarrying this PR's live label set and this body:✓ LEVEL AXIS: this PR declares clause-② yes, and no package whose packages/*/src/** it moves is graded patch, withcarrier: needs:contract-review IS on this PR. This body now writes the machine spellingClause-②:with a HYPHEN, so the declaration is read from the body itself and not from the carrier alone — round 3's near-miss (Clause ②:with a space) is fixed here.test/typecheck. Everything else in the derived list is matched through a broadpackages/**orpackages/*/src/**job filter rather than through an input this diff supplies — and CI runs the whole farm regardless.origin/mainwith 2 files it derives from changed —.github/workflows/partof-closing-keyword-guard.ymlandscripts/check-partof-closing-keyword.mjs. Those two ARE thecheck:partof-closing-keywordfamily, which is already in the 57 and is marked checker-health-only, so the gap adds no family.origin/mainmoved 28 commits during this round's work; the merge is againstf377394ae2c.Round 3 verification, taken at
f3661ac079eClean tree, exit codes captured by redirect-then-capture, never after a pipe.
Test Files 4 passed (4) / Tests 78 passed (78)— 69 before this round.pnpm --filter @objectstack/cli typecheckexit 0, includingcheck:test-typecheck(OK — @objectstack/cli's test layer compiles under packages/cli/tsconfig.test.json; 3 file(s) / 28 error(s) / 6 pinned signature(s) held, unchanged from before this round). Coverage was measured, not assumed:tsc -p tsconfig.json --noEmit --listFileslistssrc/commands/generate.ts,src/commands/generate-string-family-width.pin.test.tsandpackages/drivers/driver-sql/dist/index.d.ts, so the oracle's import is type-checked against the artifact it actually resolves.pnpm lint— the FULL repo sweep this round (eslint . --no-inline-config), exit 0 in 114s on a shared box. No narrowing, so no narrowing argument is owed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the tree atf3661ac079e; the six families these paths actually implicate were run and are green in their own verdict lines:check:nul-bytes,check:test-source-alias(72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/— this import adds none),check:cross-package-test-inputs(27 package(s) read outside themselves, all declared),check:type-source-resolution(125 tsc program(s) across 78 packages scanned; 61 registered),check:partof-closing-keyword,check:changeset-gate-self-tests.check:nul-bytes, lint), and the CLI package's owntest/typecheck. Everything else in the derived list is matched through a broadpackages/**orpackages/*/src/**job filter rather than through an input this diff supplies — and CI runs the whole farm regardless.Round 2 verification
Every round-2 reading below was taken at
9cc1a76df2c, on a clean tree, and every exit code was captured by redirect-then-capture — never after a pipe.Test Files 4 passed (4) / Tests 69 passed (69)(61 before that round).pnpm --filter @objectstack/cli run typecheckexit 0, includingcheck:test-typecheck—OK — @objectstack/cli's test layer compiles under packages/cli/tsconfig.test.json. Coverage of the two edited files was measured rather than assumed:tsc --noEmit --listFileslists bothsrc/commands/generate.tsandsrc/commands/generate-string-family-width.pin.test.ts.eslint --no-inline-config --format jsonover the two edited TypeScript files reports 2 file entries, 0 errors, 0 warnings, 0 suppressed. The narrowing is safe to read as a measurement because this repo's singleeslint.config.mjsnever enables type-aware linting for any file (noparserOptions.project, no typed rules — stated and measured in that file's own header), so no edit here can move a verdict on a file this run did not read.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackagainst the round-2 diff: 124 commands across 54 matched families. The subset actually implicated by these paths was run and is green in its own words —check:nul-bytes(no raw ASCII control bytes, 7986 files),check-keyed-text-bounds(148 keyed text-family columns judged, 148 bounded) plus its self-test,check:cross-package-test-inputs(27 package(s) read outside themselves, all declared),check:test-source-alias,check:comment-mask-adoptionand the 6209-file corpus sweep, the four changeset gates pluscheck:changeset-gate-self-tests,check:doc-authoring,check:undeclared-dep-imports,check:closing-keyword-parity,check:error-code-casing,check:type-source-resolution,check:published-files, andcheck:i18n/check:i18n-coverage/check:i18n-walk-parity/check:i18n-stale-fill.PREREQUISITE NOT METand were read as NOT MEASURED rather than as passes: the i18n trio needs the built CLI. The build closure they name was run and all three were then converted into real readings —check-i18n-bundles: OK (9 package(s) — all bundles in sync),check-i18n-coverage: OK (13 config(s), 621 baselined untranslated string(s), none new),check-i18n-walk-parity: 11 declared group(s), 8 walked, 3 exempted.git merge-tree --write-tree HEAD origin/mainexited 0 againstorigin/mainat6c546ab9d0b— a clean merge at that point. The gate derivation ran on a tree behind thatorigin/main, so the workflow churn across the gap was read directly: the only gate familiesorigin/mainadds arecheck:release-index-currency-syncandrelease-verify-npm.mjs --self-test, both release-tooling self-tests reached by no path of this diff.Clause-②: yes, graded from this diff, and no round since has changed it — the generators consult
field.uniqueandindexes[]on top ofmaxLength, all keys they have never read, so a declaration that produced one column yesterday produces another today. All new symbols ingenerate.tsare module-private.Governed surfaces (
docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md): none touched.packages/drivers/**: none touched — rounds 3 and 4 mutate it as a MEASUREMENT and restore it, proved by blob hash equal to the HEAD blob, an emptygit diff HEAD, a clean whole-treegit status --porcelainand a rebuild plus--absentpreflight after every leg. The changeset staysminor.Filed, not repaired here
required— which ADR-0113 moved the driver OFF — never readstorage.notNull, and dropdefaultValueentirely: 4 of 6 probed columns diverge on live Postgres #16294 — both generators bind an authored column'sNOT NULLtorequired, which ADR-0113 explicitly moved the driver OFF; neither readsstorage.notNull; neither emits the columnDEFAULTthe driver produces fromdefaultValue. 4 of 6 probed columns diverge, in both directions.os generate migrationemits no declared index at all — a generated table carries none of the object'suniqueconstraints, while driver-sql creates them #16317 — neither generator emits a declared index at all, so a generated table carries none of the object'suniqueconstraints whiledriver-sqlcreates them. Sharper after this change, since the key set is now computed ingenerate.tsand still not emitted.real, generatorsnumeric, andratingisrealagainstinteger#16318 — the NUMERIC family diverges in both formats: driverrealagainstnumeric, andratingrealagainstinteger. Seven of seven, on the plainest declaration. Not a mechanical repair —realis lossy forcurrency, so which side moves is a decision.typeto DIFFERENT families —stringversustext, so the unvalidated authoring door produces two different columns #16319 —driver-sqland both generators default an absent or unknowntypeto different families (stringversustext), so the unvalidated authoring door produces two different columns.Draft, auto-merge unarmed.